Skip to main content

Using the Entity Component System (ECS)

The Entity Component System (ECS) is a core architectural pattern used in this project. It provides a flexible and efficient way to structure game objects and their behaviors.

Creating Entities​

Entities are created using the Registry class. Here's how you can create a new entity:

core::ecs::Entity entity = registry.spawn_entity();

Adding Components​

Components are added to entities using the add_component method of the Registry class. For example, to add a TransformComponent to an entity:

registry.add_component(entity, core::ge::TransformComponent{position, size, scale, rotation});

You can see an example of this in the EntityFactory::createPlayer method:

core::ecs::Entity EntityFactory::createPlayer(core::ecs::Registry& registry, const sf::Vector2f& position, int color, Game &game, sf::Vector2f gameScale)
{
core::ecs::Entity player = registry.spawn_entity();

registry.add_component(player, core::ge::TransformComponent{position, sf::Vector2f(33.0f, 17.0f), gameScale, 0.0f});
registry.add_component(player, VelocityComponent{10.0f, 10.0f});

...
return player;
}

Creating Systems​

Systems are created by adding them to the registry using the add_system method. Systems operate on entities with specific components. For example, the input system:

void Game::inputSystem(core::ecs::Registry& registry)
{
registry.add_system<core::ge::TransformComponent, VelocityComponent, InputStateComponent, ShootCounterComponent>
([&](core::ecs::Entity, core::ge::TransformComponent &transform, const VelocityComponent &vel, InputStateComponent &input, ShootCounterComponent &shootCounter) {
if (input.up)
transform.position.y -= vel.dy;
if (input.down)
transform.position.y += vel.dy;
if (input.left)
transform.position.x -= vel.dx;
if (input.right)
transform.position.x += vel.dx;
});
}

This system operates on entities that have TransformComponent, VelocityComponent, InputStateComponent, and ShootCounterComponent.

Using the Game Engine

The Game Engine provides a high-level interface for managing game objects, rendering, and game logic.

Initializing the Game Engine​

The Game Engine is initialized in the Game class constructor:

Running the Game Loop​

The game loop is implemented in the run method of the Game class:

void Game::run() {
while (_windowOpen) {
sf::Time elapsed = _gameEngine.clock.restart();
_gameEngine.delta_t = elapsed.asSeconds();

processEvents();
update();
render();
}
}

This loop continuously processes events, updates the game state, and renders the scene.

Adding Game Objects​

Game objects (entities with components) are typically added using factory methods. For example, the EntityFactory::createPlayer method:

core::ecs::Entity EntityFactory::createPlayer(core::ecs::Registry& registry, const sf::Vector2f& position, int color, Game &game, sf::Vector2f gameScale)
{
core::ecs::Entity player = registry.spawn_entity();

registry.add_component(player, core::ge::TransformComponent{position, sf::Vector2f(33.0f, 17.0f), gameScale, 0.0f});
registry.add_component(player, core::ge::CollisionComponent{PLAYER, {sf::FloatRect(0.0f, 0.0f, 33.0f, 17.0f)}, {
{ ENEMY, [&](const core::ecs::Entity self, [[maybe_unused]] const core::ecs::Entity other) {
registry.kill_entity(self);
game.releaseColor(color);
}}}});
registry.add_component(player, VelocityComponent{10.0f, 10.0f});
registry.add_component(player, InputStateComponent{});
registry.add_component(player, core::ge::KeyBinding{});
registry.add_component(player, HealthComponent{10});
registry.add_component(player, ScoreComponent{0});
registry.add_component(player, Player{});
registry.add_component(player, ShootCounterComponent{0});
registry.add_component(player, PlayerColorComponent{color});

std::string relativePath = "assets/player_sprite.png";
std::string absolutePath = std::filesystem::absolute(relativePath).string();
auto texture = std::make_shared<sf::Texture>();

if (!texture->loadFromFile(absolutePath)) {
std::cerr << "Failed to load texture: " << absolutePath << std::endl;
return player;
}

sf::RectangleShape playerShape(sf::Vector2f(33.0f, 17.0f));
playerShape.setTexture(texture.get());

int row = color * 17;
playerShape.setTextureRect(sf::IntRect(0, row, 33, 17));

registry.add_component(player, core::ge::DrawableComponent{playerShape});
registry.add_component(player, core::ge::SceneComponent{static_cast<int>(Game::GameState::Playing)});
registry.add_component(player, core::ge::TextureComponent{texture});

std::vector<sf::IntRect> moveFrames;
moveFrames.reserve(5);
for (int i = 0; i < 5; i++) {
moveFrames.emplace_back(i * 33, row, 33, 17);
}
registry.add_component(player, core::ge::AnimationComponent{
.animations = {
{core::ge::AnimationState::Moving, moveFrames}
},
.frameTime = 0.1f,
.elapsedTime = 0.0f,
.currentFrame = 0,
.loop = true
});

return player;
}

Algorithmic Choices and Justifications

Sparse Array for Component Storage​

We chose to use a Sparse Array data structure for storing components. This decision was made for several reasons:

  1. Memory Efficiency: Sparse arrays allow us to efficiently store components for entities with non-contiguous IDs.
  2. Fast Access: Accessing components by entity ID is an O(1) operation.
  3. Flexibility: It's easy to add or remove components dynamically.

Event Pool for Event Management​

We implemented an Event Pool system for managing game events. This choice was made for the following reasons:

  1. Decoupling: It allows us to decouple event generation from event handling.
  2. Efficient Processing: Events can be processed in batches, which is more efficient than processing them immediately as they occur.
  3. Thread Safety: The event pool can be made thread-safe, allowing for concurrent event generation and processing.

Getting Started​

The EventPool is designed to be used as a "singleton." This means that there is only one EventPool in the game, and everyone uses the same one. To get this instance, you use the getInstance function:

// Get access to the EventPool
EventPool& eventPool = EventPool::getInstance();

Now, you have access to the EventPool and can start adding or retrieving events.

Adding an Event​

To add a new event to the EventPool, you use the addEvent function. Let’s say a player has moved in the game:

// Create a PlayerMovement event
PlayerMovement movement = {1, 100.0f, 200.0f, 0.0f}; // Player ID 1 moves to (100, 200, 0)
Event movementEvent(0x01, movement); // 0x01 is the event type for PlayerMovement

// Add this event to the pool
eventPool.addEvent(movementEvent);

This will put the movement event in the queue, ready to be processed later.

Getting the Next Event​

Once you have added events to the EventPool, you might want to retrieve the next one to see what needs to be handled:

// Get the next event in the queue
Event nextEvent = eventPool.getNextEvent();

This will give you the next event from the queue and remove it, so you can handle it in your game.

Getting All Events at Once​

If you want to process all the events that have been stored in the EventPool, you can get all of them at once:

// Retrieve and process all events
...
// Handle player movement
} else if (e.getType() == 0x05) {
// Handle player shoot
}
// Add more cases for other types of events...
}
}

Summary​

The EventPool makes it easy to keep track of all the actions happening in the game. You can add new events when things happen (like a player moving or an enemy appearing), and then process them later when you’re ready. It’s like a to-do list for game events!

Custom Network Protocol (GDTP)​

We developed a custom network protocol, Game Datagram Transfer Protocol (GDTP), for efficient game state synchronization. The reasons for this choice include:

  1. Low Latency: By using UDP and a lightweight protocol, we minimize network latency.
  2. Bandwidth Efficiency: The binary protocol reduces the amount of data that needs to be transferred.
  3. Flexibility: The custom protocol allows us to easily add new message types as needed.
info

You can refer to the [protocol documentation](../../../Network/Game Datagram Transfer Protocol (GDTP)).